web/src/routes/vms/[id]/+page.svelte
Ref: Size: 16.3 KiB History
<script lang="ts">
import { page } from '$app/state';
import Console from '$lib/Console.svelte';
import SshConnect from '$lib/SshConnect.svelte';
import {
fleet,
action,
setPower,
deleteVM,
restoreVM,
vmStatus,
teardownApprox,
vmPower,
vmIP,
VM_IP_HINT,
vmNetworkValue,
vmNetworkAddr,
vmNetworkAddrHint,
vmDetail,
sessionSummary,
vmPowerAction,
deleteConfirm,
vmEvents,
eventLabel,
listExposures,
createExposure,
deleteExposure,
formatHostPort,
shortFingerprint,
clock,
type VMEvent,
type Exposure
} from '$lib/fleet.svelte';
const id = $derived(page.params.id);
const vm = $derived(fleet.vms.find((v) => v.id === id));
const host = $derived(vm ? fleet.hosts.find((h) => h.id === vm.host_id) : undefined);
// Depend on the primitive lifecycle, not the whole vm object: fleet.vms is
// reassigned ~1/s over SSE (new object refs), so reading vm.lifecycle through
// the derived would refetch on every push. The string only changes on a real
// lifecycle transition.
const lifecycle = $derived(vm?.lifecycle ?? '');
// status is the VM's lifecycle as the page speaks it (see vmStatus).
const status = $derived(vm ? vmStatus(vm) : '');
// tearingDown gates the restore affordance: the VM is quarantined for
// teardown (deleted).
const tearingDown = $derived(status === 'deleting');
// powerAction is the flip the button offers, or null when the VM's moment
// admits none (see vmPowerAction).
const powerAction = $derived(vm ? vmPowerAction(vm) : null);
// powerNote stands where the power control would be when there is none: the
// state itself, which for a create in flight is worth a few more words.
const powerNote = $derived(
status === 'creating' ? 'creating—eitri is bringing this VM up' : status
);
// A guest is reachable once it exists and can be certified — so the connect
// recipes appear for a settled VM only. A creating one may not have a signed
// host certificate yet, and a failed one has nothing listening.
const connectable = $derived(status === 'ready' || status === 'stopped');
// approx is the coarse "time left to undo" shown in the teardown callout.
const approx = $derived(vm ? teardownApprox(vm, clock.now) : '');
let events = $state<VMEvent[]>([]);
let eventsFailed = $state(false);
// forId captures which VM the fetch was for: navigating A→B reuses this
// component, so A's in-flight request must not clobber B's newer results.
async function loadEvents(forId: string | undefined) {
if (!forId) return;
try {
const evs = await vmEvents(forId);
if (forId !== id) return; // navigated away mid-flight
events = evs;
eventsFailed = false;
} catch {
if (forId !== id) return;
eventsFailed = true;
}
}
// Reload the timeline on mount and whenever the VM's lifecycle advances, so
// new events (delete, restore, reap…) appear as it progresses.
$effect(() => {
// track both id and lifecycle so a re-fetch fires when either changes.
void lifecycle;
// Clear stale history immediately on id change so the previous VM's
// events don't flash before the new fetch resolves.
events = [];
eventsFailed = false;
loadEvents(id);
});
let exposures = $state<Exposure[]>([]);
let exposuresFailed = $state(false);
let guestPortInput = $state('');
let hostPortInput = $state('');
let protocolInput = $state('tcp');
async function loadExposures(forId: string | undefined) {
if (!forId) return;
try {
const list = await listExposures(forId);
if (forId !== id) return; // navigated away mid-flight
exposures = list;
exposuresFailed = false;
} catch {
if (forId !== id) return;
exposuresFailed = true;
}
}
// Reload alongside the timeline: an exposure's state is what its host last
// reported, so it moves as the VM does.
$effect(() => {
void lifecycle;
exposures = [];
exposuresFailed = false;
loadExposures(id);
});
// exposureTarget is the address:port to dial, or '' while no address is
// known yet — an empty host_addr means the host hasn't reported one, never
// that the exposure has none, so the row says so rather than printing
// something undialable. The SSE-fed host is the second source, so a row
// heals within a tick of its host reporting instead of waiting for a refetch.
function exposureTarget(e: Exposure): string {
const addr = e.host_addr || host?.uplink_addr || '';
return addr ? formatHostPort(addr, e.host_port) : '';
}
// exposureState is the state a row shows. A VM being torn down dominates:
// its host stops reporting the listeners, so every row would otherwise read
// "pending" while the truer answer is that the VM is going away.
function exposureState(e: Exposure): string {
return tearingDown ? 'deleting' : e.state;
}
async function addExposure(e: SubmitEvent) {
e.preventDefault();
if (!vm) return;
const guest = Number(guestPortInput);
const host = hostPortInput === '' ? 0 : Number(hostPortInput);
const proto = protocolInput;
const vmId = vm.id;
if (await action(() => createExposure(vmId, guest, host, proto))) {
guestPortInput = '';
hostPortInput = '';
protocolInput = 'tcp';
await loadExposures(vmId);
}
}
async function removeExposure(exposureId: string) {
if (!vm) return;
const vmId = vm.id;
if (await action(() => deleteExposure(exposureId))) {
await loadExposures(vmId);
}
}
async function power(p: 'running' | 'stopped') {
if (!vm) return;
const vmId = vm.id;
await action(() => setPower(vmId, p));
}
async function remove() {
if (!vm) return;
if (!confirm(deleteConfirm(vm))) return;
const vmId = vm.id;
await action(() => deleteVM(vmId));
}
async function restore() {
if (!vm) return;
const vmId = vm.id;
// The SSE stream flips the VM back to live on its own; no local poke.
await action(() => restoreVM(vmId));
}
</script>
<p><a href="/">← fleet</a></p>
{#snippet timelineSection()}
<div class="timeline">
<h3>Lifecycle</h3>
{#if eventsFailed}
<p class="hint">couldn't load history</p>
{:else if events.length === 0}
<p class="hint">No events recorded yet.</p>
{:else}
<table class="kv">
<tbody>
{#each events as ev, i (ev.at + ev.action + i)}
<tr>
<th>{ev.at}</th>
<td>{eventLabel(ev)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{/snippet}
{#if !vm}
<p class="hint">VM not found—it may have been destroyed. Its recorded lifecycle history:</p>
{@render timelineSection()}
{:else}
<h2>{vm.name}</h2>
{#if tearingDown}
<!-- A VM on its way out qualifies the whole page—there is no console to
watch and no port left to publish—so its box sits above both
columns rather than in either one. -->
<div class="teardown-callout">
<h3>This VM is being deleted</h3>
{#if approx}
<p>
The guest is stopped. Its disk still holds everything that was on it, and is destroyed in
{approx}. Restoring brings the VM back and boots it from that same disk.
</p>
{:else}
<!-- No destroy_at: the host has not reported taking the tombstone, so
the countdown has not started and neither has the shutdown, as
far as anything here knows. -->
<p>
eitri has asked this VM's host to stop the guest and destroy it. Until the host takes
the tombstone the guest may still be running, and the clock on the disk starts then.
Restoring brings the VM back and boots it from that same disk, with everything that was
on it.
</p>
{/if}
<button class="restore" onclick={restore}>Restore VM</button>
</div>
{/if}
<div class="columns">
<div class="facts">
<div class="scroll">
<table class="kv">
<tbody>
<tr><th>ID</th><td>{vm.id}</td></tr>
<tr><th>Host</th><td>{#if host}<a href="/hosts/{host.id}">{host.name}</a>{:else}{vm.host_id}{/if}</td></tr>
<tr>
<th>Status</th>
<td>
{vmStatus(vm)}
<!-- What the host is doing about this VM right now, when it is
doing something and has said so (see vmDetail). -->
{#if vmDetail(vm)}<span class="hint detail">{vmDetail(vm)}</span>{/if}
</td>
</tr>
<tr><th>Power</th><td>{vmPower(vm)} (desired: {vm.power_state})</td></tr>
<!-- One row for the whole story: the network the operator named at
create, and — once that network's own DHCP has answered — the
second address it granted this guest, joined onto the same
value. Nothing here is a word eitri invented: the label matches
the API field, and the name in the value is the operator's own. -->
{#if vmNetworkValue(vm)}<tr><th>Network</th><td>{vmNetworkValue(vm)}{#if vmNetworkAddr(vm)} <span class="hint">{vmNetworkAddrHint(vm)}</span>{/if}</td></tr>{/if}
<tr><th>IP</th><td>{vmIP(vm)} <span class="hint">{VM_IP_HINT}</span></td></tr>
<tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr>
<tr><th>Image</th><td class="wrap">{vm.image_url}</td></tr>
<tr>
<th>Trusted CAs</th>
<td>
{#if !vm.trusted_cas}
<span class="hint"
>unrecorded—this guest predates the record. It trusts whatever CAs its tenant
had registered on the day it was created.</span
>
{:else}
<ul class="cas">
{#each vm.trusted_cas as ca (ca.fingerprint + ca.label)}
<li>
{ca.label || 'unlabelled'}
<code>{shortFingerprint(ca.fingerprint) || 'unreadable key'}</code>
</li>
{/each}
</ul>
<span class="hint"
>fixed when this guest was created—a CA registered since does not reach it</span
>
{/if}
</td>
</tr>
<tr>
<th>Injected key</th>
<td>
{#if !vm.injected_key}
<span class="hint"
>none—no extra key was named at create, which is the usual case. Access is by
certificate, from the CAs above.</span
>
{:else if vm.injected_key.fingerprint}
<code class="wrap">{vm.injected_key.fingerprint}</code>
<span class="hint"
>{vm.injected_key.type}{#if vm.injected_key.comment}, {vm.injected_key
.comment}{/if}—an extra key installed at create, alongside the CA
trust above</span
>
{:else}
<span class="hint"
>{vm.injected_key.type}—unreadable, so no fingerprint. The guest's sshd decides
whether it works.</span
>
{/if}
</td>
</tr>
{#if vm.last_error}<tr><th>Last error</th><td class="err">{vm.last_error}</td></tr>{/if}
<tr><th>Created</th><td>{vm.created_at}</td></tr>
</tbody>
</table>
</div>
</div>
<div class="doing">
{#if !tearingDown}
<div class="actions">
{#if powerAction === 'stop'}
<button class="ghost" onclick={() => power('stopped')}>Stop</button>
{:else if powerAction === 'start'}
<button class="ghost" onclick={() => power('running')}>Start</button>
{:else}
<span class="hint">{powerNote}</span>
{/if}
<button class="danger" onclick={remove}>Delete</button>
</div>
{/if}
<!-- A plane with no gate has no hop to name, so it prints no recipe rather
than commands that cannot work. -->
{#if connectable && fleet.me?.ssh_gate}
<SshConnect vmName={vm.name} tenant={fleet.me.tenant} gate={fleet.me.ssh_gate} />
{/if}
<div class="exposures">
<h3>Exposures</h3>
{#if exposuresFailed}
<p class="hint">couldn't load exposures</p>
{:else if exposures.length === 0}
<p class="hint">
No published ports. The SSH gate reaches this guest; anything else it serves needs a
port published here.
</p>
{:else}
<div class="scroll">
<table class="kv">
<tbody>
{#each exposures as e (e.id)}
<tr>
<th>guest :{e.guest_port}/{e.protocol}</th>
<td>
{#if exposureTarget(e)}
<code>{exposureTarget(e)}</code>
{:else}
<span class="hint">host address not yet known · :{e.host_port}</span>
{/if}
<span class="state {exposureState(e)}">●</span> {exposureState(e)}
{#if e.reason && !tearingDown}<span class="err">{e.reason}</span>{/if}
<!-- Only a port whose host actually counted gets this line; see
sessionSummary. -->
{#if sessionSummary(e) && !tearingDown}
<span class="hint detail">{sessionSummary(e)}</span>
{/if}
</td>
<td>
{#if !tearingDown}
<button class="ghost" onclick={() => removeExposure(e.id)}>Remove</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if !tearingDown}
<form class="expose-form" onsubmit={addExposure}>
<input
type="number"
min="1"
max="65535"
required
bind:value={guestPortInput}
placeholder="guest port"
/>
<input
type="number"
min="1024"
max="65535"
bind:value={hostPortInput}
placeholder="host port (optional)"
/>
<select bind:value={protocolInput} aria-label="protocol">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
<button type="submit">Expose</button>
</form>
{/if}
<p class="hint">
Anything that can reach the host can reach a published port—there is no authentication in
front of one.
</p>
</div>
</div>
</div>
<!-- The console is a terminal: it takes the full measure, under both
columns, and the VM's history follows it as before. -->
{#if !tearingDown}
<Console vmId={vm.id} />
{/if}
{@render timelineSection()}
{/if}
<style>
h2 {
font-size: inherit;
}
.kv th {
vertical-align: top;
}
.wrap {
word-break: break-all;
white-space: normal;
}
.err {
color: var(--bad);
}
/* A fact's quieter second half: what is happening under a status, what a
published port has carried. It carries the global .hint colour and adds
only the line of its own — it is a sentence, not a value, and reading it
run together with the fact above it is what a line break prevents. */
.detail {
display: block;
}
/* The trusted CAs are a list inside a value cell: unbulleted and flush, so
the row still reads as one fact with several parts rather than as a
nested table. Usually one or two entries. */
.cas {
list-style: none;
margin: 0;
padding: 0;
}
/* Given the width, the page stops being one column of everything: what this
VM IS reads down the left, what you can DO with it stands to the right.
Space alone divides them—a vertical rule beside a ruled table would read
as a box, and this vocabulary rules rows rather than drawing cells.
Narrower than the breakpoint there is no grid at all: the blocks stack in
source order, which is why the facts come first in the markup. */
/* The .columns grid and the .scroll boxes it holds are the shared
two-column vocabulary (:global in +layout.svelte); only this page-local
nudge stays here. */
@media (min-width: 1100px) {
/* Both columns start on the same line. */
.doing .actions {
margin-top: 0.5em;
}
}
.actions {
display: flex;
align-items: center;
gap: 0.5em;
margin-top: 0.8em;
}
/* A VM on its way out is worth a box of its own, so the undo is not one
button among several. Ink border, like the modal: this one is loud. */
.teardown-callout {
border: 1px solid var(--ink);
padding: 0.7em 0.9em;
margin-top: 0.9em;
max-width: 72ch;
}
.teardown-callout h3 {
margin: 0 0 0.3em;
font-size: inherit;
}
.teardown-callout p {
margin: 0 0 0.6em;
}
.exposures {
margin-top: 1.2em;
}
.exposures h3 {
margin: 0 0 0.2em;
font-size: inherit;
}
.exposures .kv th {
width: 20ch;
}
/* The glyph takes the state's colour; the word beside it stays ink, so the
state reads without it. */
.state {
margin-left: 0.5em;
}
.state.active {
color: var(--ok);
}
.state.failed {
color: var(--bad);
}
.state.pending,
.state.deleting {
color: var(--faint);
}
/* The fields wrap rather than overflow: in a column they no longer have a
whole page to sit across. */
.expose-form {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
margin-top: 0.5em;
}
.expose-form input {
width: 24ch;
}
.timeline {
margin-top: 1.2em;
}
.timeline h3 {
margin: 0 0 0.2em;
font-size: inherit;
}
.timeline .kv th {
width: 30ch;
}
</style>